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)
PoC verification
Evidence pack
findings/poc/DF-5699 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:2249compiles 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 forPROP_TYPE_NUMBERin this proplib.) - Experiment:
killer: prop_dictionary_remove() doneβ handler's post-sleepprop_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 rigfix_build.sh/fix_run.shβ patched rig (fix_validation)prop_ingest_fixed.cβ the patched copy fix.diff was generated frombuild.log run.log run.2.log run.3.log fix_build.log fix_run.log fix_run.2.log env.txtfix.diffβ git-apply-able unified diff againstsys/libprop/prop_ingest.c
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.shcompiles/usr/src/sys/libprop/*.c(unmodified) intolibprop_orig.so; the harness links against it β the exact source audited.- Harness: thread A ingests
{"victim": <number 0x1337beef00>}with a handler that snapshotsobj, sleeps 400 ms, then readsprop_object_type(borrowed)+prop_number_integer_value(borrowed). Thread B (during the sleep) callsprop_dictionary_remove(dict,"victim")β the prop_number is not interned (prop_number.c:273_prop_number_allocplain-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_UNKNOWNis 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.
ctxreuse resets onlypic_error(:129); stalepic_key/pic_typeafter 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
fixedRig-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.
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.
No comments yet.