equals-walk type-mismatch early return abandons the _prop_stack: every ancestor's rwlock leaks permanently and (>=17 deep) each kmalloc'd extern element leaks β violation of the stack's pop-to-empty/pot_equals_finish contract
Summary
The only exit of the equals walk that skips the finish: drain - each abandoned entry holds an ancestor's locks (taken at first iteration, released only by pot_equals_finish during the drain) and a kmalloc'd extern element beyond depth 16. Permanent rdlock leak: the next mutation's wrlock blocks FOREVER; plus ~48B heap per comparison past depth 16. Rig 4/4 deterministic: type-mismatch equals -> subsequent add wedges (alarm proves the block), 400k equals grows RSS 0->19.3MB matching prediction; goto-finish fix validated (add completes, RSS flat). Userland libprop live from the same source; kernel latent (no in-tree caller drives the nested equals walk - only leaf equals_cstring). DoS ceiling.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5704 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.7 KB | β raw | |
| VERDICT.md | β | 5.0 KB | β raw | |
| df5704_userland.c | β | 5.6 KB | view raw | |
| build.sh | β | 128 B | view raw | |
| run.sh | β | 165 B | view raw | |
| build.log | β | 16 B | view raw | |
| run.userland.log | β | 1.1 KB | view raw | |
| run.2.log | β | 198 B | view raw | |
| fixtest.userland.log | β | 1.1 KB | view raw | |
| env.txt | β | 297 B | view raw | |
| fix.diff | β | 386 B | view raw | |
| verdict.json | β | 5.2 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw |
DF-5704 β equals-walk stack abandonment leaks ancestor locks + extern stack elements
File audited (pass 2): sys/libprop/prop_stack.c (the explicit stack used by
the iterative internalize/release/equals walks).
Defect: prop_object_equals_with_error() returns early on a nested
type mismatch without draining its _prop_stack
(sys/libprop/prop_object.c:1155-1156), violating the stack's pop-to-empty +
pot_equals_finish contract. Because _prop_array_equals() /
_prop_dictionary_equals() take both objects' rwlocks on the first
iteration and keep them held across _PROP_OBJECT_EQUALS_RECURSE
(sys/libprop/prop_array.c:219-243, sys/libprop/prop_dictionary.c:503-...),
and only pot_equals_finish() (invoked by the finish: drain loop at
prop_object.c:1184-1189) releases them, the abandoned stack entries leak:
- Permanent shared-lock leak on every ancestor pair β in the kernel
(
struct mtx,prop_object_impl.h:295-300) the shared count never drops, so any later writer of those objects blocks forever (unkillable-thread local DoS); in userland (pthread rwlock, same source) identical. - Heap leak of the stack's growth elements β beyond 16 nesting levels
each pushed level is a
kmalloc/malloc'd_prop_stack_extern_elem(sys/libprop/prop_stack.c:49-63, 40/48 bytes) that is only freed by_prop_stack_pop(); abandoning the stack leaks it.
Kernel reachability today: latent β no in-tree kernel caller drives the
nested equals walk (only leaf prop_string_equals_cstring at
sys/kern/kern_udev.c:906, sys/dev/disk/dm/device-mapper.c:299). Userland
libprop builds the same source (lib/libprop/Makefile:6-13), where
prop_array_equals/prop_dictionary_equals are public API and the bug is
deterministically reachable by any program comparing nested structures with a
nested type mismatch.
Reproduce (guest, root, ~2 min)
sh build.sh # same-source rig: /usr/src/sys/libprop -> libprop_orig.so + harness
sh run.sh # A) wedge: control unwedged / buggy wedged (exit 42 = still blocked)
# B) leak: RSS 0 -> 19.3 MB over 400k equals() (~48 B/call)
Expected decisive output (run.userland.log):
-> child STILL BLOCKED in prop_array_add after 5s: rdlock LEAKED (exit 42) DF-5704 wedge result: REPRODUCED (control unwedged, buggy wedged) [leak] after 400000 equals(): ru_maxrss = 19352 kB
Fix validation
fix.diff routes the type mismatch through the existing finish: drain
(one-line change in sys/libprop/prop_object.c). Applied to the same rig
(propfix/), rebuilt, re-run: buggy case add returned TRUE (no wedge),
RSS flat at ~1010 kB over 400k equals() (fixtest.userland.log).
DF-5704 VERDICT β REPRODUCED (deterministic, same-source userland differential; kernel-latent today)
What was claimed
Auditing sys/libprop/prop_stack.c (pass 2) against its contract revealed the
equals walk violates the stack's drain discipline: on a nested type
mismatch prop_object_equals_with_error() returns false immediately
instead of unwinding its _prop_stack. Each abandoned stack level represents
an ancestor pair whose shared locks are still held (taken by
_prop_array_equals/_prop_dictionary_equals at first iteration, released
only by pot_equals_finish during the finish: drain) plus β beyond 16
levels β a heap-allocated _prop_stack_extern_elem that only
_prop_stack_pop() frees.
Why it is a real bug (path:line)
sys/libprop/prop_object.c:1155-1156βif (po1->po_type != po2->po_type) return (false);β the only exit of the walk that skips thefinish:drain atprop_object.c:1184-1189.sys/libprop/prop_array.c:219-227β bothpa_rwlocks rd-locked atidx==0(address-ordered);sys/libprop/prop_array.c:240-243β_PROP_OBJECT_EQUALS_RECURSEreturned with the locks held;sys/libprop/prop_array.c:251-256β_prop_array_equals_finishunlocks both (only called from the drain);- same pattern for dictionaries:
sys/libprop/prop_dictionary.c:503-511. sys/libprop/prop_stack.c:49-63β push beyondPROP_STACK_INTERN_ELEMS(16) heap-allocates_prop_stack_extern_elem(_PROP_MALLOC), freed only atprop_stack.c:101inside_prop_stack_pop.- Stack contract documented by construction: every other exit drains
(
prop_object.c:638-641, 650-656, 1106, 1165-1170, 1184-1188); the equals type-mismatch exit is the sole violation.
How it was proven (guest, DragonFly 6.5-DEVELOPMENT)
Same-source rig (the established pattern): /usr/src/sys/libprop compiled
into libprop_orig.so on the guest, harness linked against it β the bytes
executed are the kernel's own source text.
A) Wedge (deterministic, 3/3 runs + 1 initial = 4/4):
a1=["x"] vs a2=[5] β type mismatch at depth 1.
- Control (a1=["x"] vs a2=["y"] β types match): equals -> 0, then
prop_array_add returns immediately (FALSE path drains correctly).
- Buggy: equals -> 0, then prop_array_add never returns; SIGALRM(5s)
handler _exit(42)s from inside the blocked call.
β the rdlock taken by _prop_array_equals on a1 (and a2) was leaked by
the abandoned stack entry; the write path (prop_array_add β
_PROP_RWLOCK_WRLOCK, prop_array.c:638) blocks forever.
In-kernel equivalent: struct mtx shared count never drops β any writer of
the object wedges forever in _mtx_lock_ex (kernel thread DoS).
B) Heap leak of the growth elements: 17-deep chains, mismatch at the leaf
β exactly one _prop_stack_extern_elem is live on the stack at the moment of
abandonment. 400,000 prop_array_equals() on the same pair:
ru_maxrss 0 β 19,352 kB (~48 B/iteration = one malloc chunk per call,
matching prediction iters Γ 48B β 18,750 kB).
Exploit chain / impact ceiling
- Userland libprop (same source, shipped as /lib/libprop): any program that compares attacker- or data-influenced nested plists with a nested type mismatch permanently wedges a thread on the next mutation of those objects and leaks memory per comparison. Local DoS, deterministic.
- Kernel: the identical code compiled into the kernel; today no
in-tree caller drives the nested equals walk (verified: only
prop_string_equals_cstringatsys/kern/kern_udev.c:906andsys/dev/disk/dm/device-mapper.c:299β leaf compares, no stack). Any future kernel consumer ofprop_array_equals/prop_dictionary_equals/prop_object_equalson attacker-shaped plists inherits a permanent kernel-lock leak β unkillable blocked threads (local DoS). Not uid0-able: the primitive is a lock/heap leak, not memory corruption.
Fix validation
fix.diff (one line, sys/libprop/prop_object.c): route the type mismatch
through the existing finish: drain (goto finish;). The drain pops every
ancestor and calls its pot_equals_finish (unlock), then returns false β
semantics preserved. Validated in the same rig (propfix/):
- buggy case: add returned TRUE (no wedge) β wedge GONE;
- leak case: ru_maxrss flat at 1,008-1,028 kB across 400k equals() β leak GONE;
- control case: unchanged.
Classification
status: reproduced, impact: dos (permanent rwlock wedge + unbounded heap leak; deterministic in userland libprop today, latent in kernel), confidence: certain (mechanism proven at 4/4 determinism with control, leak quantified to the byte, fix validated same-source).
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.diff applied to the same-source rig inside the guest (propfix/), rebuilt, re-run: baseline wedge (add blocked forever, exit 42) and 19.3 MB RSS leak are GONE (add returns TRUE; RSS flat ~1,010 kB over 400k equals); type-matched control unchanged; equals still returns false on mismatch (semantics preserved).
fixtest.userland.log (fixed build full output: buggy case 'add returned TRUE (no wedge)', ru_maxrss 1008/1028 kB flat), fix.diff
Confirmed kernel references
- sys/libprop/prop_stack.c:49-63
- sys/libprop/prop_stack.c:86-117
- sys/libprop/prop_stack.h:50-62
- sys/libprop/prop_object.c:1149-1190
- sys/libprop/prop_array.c:219-243
- sys/libprop/prop_array.c:251-256
- sys/libprop/prop_dictionary.c:503-511
- sys/libprop/prop_object_impl.h:295-300
- sys/kern/kern_udev.c:906
- sys/dev/disk/dm/device-mapper.c:299
Detail
Exploit chain
program (or future kernel caller) compares two nested prop arrays/dicts whose element types differ at some nesting depth -> _prop_array_equals takes both objects' rwlocks and returns RECURSE -> walk pushes the pair onto _prop_stack -> next level's type check fails -> early return abandons the stack -> ancestors' rdlocks never released (pot_equals_finish never runs) + kmalloc'd extern elements never popped -> next mutation of any ancestor (prop_array_add/set -> wrlock) blocks forever; each additional comparison leaks another extern element past depth 16. Guest evidence: run.userland.log (control unwedged / buggy wedged exit 42; RSS 19,352 kB @400k), run.2.log (3/3 deterministic), fixtest.userland.log (patched: no wedge, RSS flat).
Evidence (decisive lines)
run.userland.log (decisive: 'STILL BLOCKED in prop_array_add after 5s: rdlock LEAKED (exit 42)'; 'ru_maxrss = 19352 kB' after 400k equals); run.2.log (wedge REPRODUCED 3/3); fixtest.userland.log (fixed build: 'add returned TRUE (no wedge)', RSS flat 1008-1028 kB); build.log (same-source rig build); env.txt (guest uname/cc); fix.diff (one-line goto finish)
PoC changes
No seed. Harness written fresh: fork+alarm(5) wedge probe with type-matched control, and a 17-deep RSS leak probe reusing one object pair (isolates the ~48B extern-element leak from object churn). Built against the same-source libprop_orig.so rig per the established pattern (lib/libprop/Makefile .PATH sys/libprop).
Verified recommended fix
Route the equals type-mismatch through the existing finish: drain: replace the early 'return (false)' at prop_object.c:1155-1156 with 'goto finish;' so every pushed ancestor gets pot_equals_finish (unlock) and its extern stack element popped/freed.
Verdict
REPRODUCED deterministically (4/4 runs) via the established same-source userland differential rig on the guest: prop_object_equals_with_error returns early on a nested type mismatch (prop_object.c:1155-1156) without draining its _prop_stack, so every ancestor pair's shared rwlocks (taken at prop_array.c:219-227 / prop_dictionary.c:503-511 and held across RECURSE, released only by pot_equals_finish in the finish: drain) leak permanently, and beyond 16 levels each abandoned _prop_stack_extern_elem (prop_stack.c:49-63, freed only by _prop_stack_pop at prop_stack.c:101) leaks. Wedge proof: a1=["x"] vs a2=[5] then prop_array_add blocks forever (SIGALRM exit 42) while the type-matched control completes; leak proof: ru_maxrss 0 -> 19,352 kB over 400k equals() on 17-deep chains (~48 B/call = exactly one extern element). Kernel impact is latent today (no in-tree kernel caller drives the nested equals walk; only leaf prop_string_equals_cstring at kern_udev.c:906 / device-mapper.c:299) but the same code compiles into the kernel, where the mtx shared count never drops and any future writer wedges forever. Not a memory-corruption primitive: no uid0 path, impact ceiling is unkillable-thread DoS + unbounded heap leak. fix.diff (goto finish;) validated in the same rig: wedge GONE (add returns), RSS flat ~1,010 kB, control unchanged.
No comments yet.