prop_number_unsigned() is the only prop_number accessor without a type guard - type-confused 4-byte heap OOB read (bitfield word at +40..43 vs 40-byte prop_string)
Summary
Unlike all seven sibling accessors, prop_number_unsigned dereferences pn->pn_value with no type check: struct _prop_string is exactly 40 bytes and pn_value sits at offset 40, so calling it on any non-number reads 4 bytes entirely past the heap allocation. Zero in-kernel callers today (defense-in-depth); one future caller passing a user-influenced plist object turns it into a kernel heap OOB read. Guard-page rig: probe SIGSEGVs deterministically at +40, control and fixed variants clean. add-the-guard fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5682 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 3.1 KB | β raw | |
| typeconf.c | β | 1.8 KB | view raw | |
| pguard_object_impl.h | β | 14.9 KB | view raw | |
| pfix_number.c | β | 14.9 KB | view raw | |
| build.sh | β | 75 B | view raw | |
| run.sh | β | 99 B | view raw | |
| build.log | β | 46 B | view raw | |
| run.log | β | 781 B | view raw | |
| env.txt | β | 228 B | view raw | |
| fix.diff | β | 368 B | view raw | |
| manifest.json | β | 961 B | view raw | |
| verdict.json | β | 3.6 KB | view raw |
DF-5682 β prop_number_unsigned() is the only unguarded prop_number accessor (type-confused OOB read)
What
prop_number_unsigned() (sys/libprop/prop_number.c:379-384) reads
pn->pn_value.pnv_is_unsigned without the prop_object_is_number()
validation that every sibling accessor performs:
prop_number_copyprop_number.c:364prop_number_sizeprop_number.c:396prop_number_integer_valueprop_number.c:432prop_number_unsigned_integer_valueprop_number.c:450prop_number_equalsprop_number.c:463prop_number_equals_integerprop_number.c:477prop_number_equals_unsigned_integerprop_number.c:496
Layout: struct _prop_number places pn_value at offset 40
(16-byte _prop_object + 24-byte rb_node); the bitfield word lives at
+40..43. struct _prop_string is exactly 40 bytes (16 + ptr +
size_t + int). Calling prop_number_unsigned() on a string (or data /
bool β 16 bytes β object) therefore reads 4 bytes entirely past the
heap allocation:
0 16 40
+-------------------+-------------------+---------+
| _prop_string | data/size/flags | GUARD / adjacent chunk
+-------------------+-------------------+---------+
^ pnv_is_unsigned read (+40..43)
Kernel reachability today: none β rg finds zero in-kernel callers
of prop_number_unsigned() outside libprop itself (the only kernel
consumers of prop_number creation are kern_udev.c:195,215, create-side
and type-safe). The defect is a live memory-safety trap one future
caller away from a user-influencible plist type confusion, and it is
present verbatim in the userland libprop.so built from the same
source. Filed as hardening (Info): certain code defect, no demonstrated
kernel impact.
Reproduce (rig β guard-page heap on the QEMU guest)
build.sh compiles the guest's /usr/src/sys/libprop
(md5-identical to the audited tree) twice, with _PROP_POOL_GET/
_PROP_POOL_PUT patched (userland branch of prop_object_impl.h) to
place every proplib object allocation flush against a PROT_NONE guard
page β deterministic, ASAN-free boundary detection (guest base cc 8.3
ships no usable ASAN runtime):
prop5682_pgβ guard-page allocator + stockprop_number.cprop5682_fixβ guard-page allocator +prop_number.cwith theprop_object_is_number()guard added (pfix_number.c= the exactfix.diffunder test)
typeconf.c creates one prop_string_t, then:
- control β
prop_number_integer_value((prop_number_t)str): the guarded sibling, must return 0 without trapping; - probe β
prop_number_unsigned((prop_number_t)str): the unguarded accessor.
Expected / observed (run.log, 2026-10-10)
| variant | control | probe |
|---|---|---|
| baseline (stock) | -> 0 (no trap) |
SIGSEGV (exit 139) on the guard page |
| fixed (guard added) | -> 0 (no trap) |
-> 0 (NO TRAP), exit 0 |
The 4-byte OOB read at +40 is proven deterministically, and the fix is validated on the same source.
Fix
fix.diff β add the missing guard, matching every sibling accessor:
bool
prop_number_unsigned(prop_number_t pn)
{
+ if (! prop_object_is_number(pn))
+ return (false);
+
return (pn->pn_value.pnv_is_unsigned);
}
DF-5682 β VERDICT
Status: reproduced (primitive, deterministic, same-source userland
rig) β kernel impact today: none reachable (zero in-kernel callers);
filed as Info/hardening.
The finding
prop_number_unsigned() (prop_number.c:379-384) is the only
prop_number accessor lacking the prop_object_is_number() guard its
siblings have (prop_number.c:364, 396, 432, 450, 463, 477, 496). It
reads the pnv_is_unsigned bitfield word at offset +40..43 of the
object. struct _prop_string is exactly 40 bytes; struct _prop_bool
is 16. Passing any non-number object therefore reads 4 bytes
entirely past the heap allocation β a type-confusion OOB read.
Reachability (honest)
- Kernel:
rgoversys/finds zero callers ofprop_number_unsigned()outside libprop itself; the only kernel prop_number consumers areprop_number_create_integer/prop_number_create_unsigned_integerin sys/kern/kern_udev.c:195,215 (create-side, type-safe). One future caller that fetches a value from a user-influenced plist by key and passes it untyped turns this into a kernel heap OOB read β hence hardening, not an exploitable finding today. - Userland:
lib/libpropbuilds the same source intolibprop.so; any consumer that type-confuses gets the same OOB read in-process.
What was demonstrated (run.log)
Guard-page rig on the QEMU guest: guest /usr/src/sys/libprop
(md5-identical to the audited tree, verified) compiled with
_PROP_POOL_GET/_PROP_POOL_PUT placing every object allocation flush
against a PROT_NONE page (deterministic boundary detection; guest base
cc 8.3 has no usable ASAN runtime).
- Control β
prop_number_integer_value((prop_number_t)str)(guarded sibling): returns 0, no trap β proves the guard works and the object/pool wiring is correct. - Probe β
prop_number_unsigned((prop_number_t)str)(unguarded): SIGSEGV, exit 139 on the guard page β the +40..43 read lands past the 40-byte allocation. Deterministic. - Fix β same rig with
pfix_number.c(the exactfix.diff): probe returns false, no trap, exit 0. Fix validated on the same source.
Fix validation
fix.diff adds the missing guard, matching every sibling accessor.
Validated in-rig (baseline traps 139, patched survives) β this is a
pure source-level change to a userland-buildable file, so the rig
validation is representative of the kernel build as well
(kernel compile of the guarded function is straightforward C; no
kernel-only macro is involved on this path).
Kernel refs
- sys/libprop/prop_number.c:49-62 (layout), 379-384 (defect), 364/396/432/450/463/477/496 (guarded siblings)
- sys/libprop/prop_string.c:40-50 (struct size)
- sys/libprop/prop_object_impl.h:364-365 (userland pool = malloc)
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 validated on the same source in-rig: baseline (stock prop_number.c) traps SIGSEGV exit 139 on the guard page at the +40 read; patched copy (exact fix.diff applied) returns false with no trap, exit 0, control accessor unchanged. Pure C-level change on a userland-buildable path, representative of the kernel build.
run.log sections 1 (baseline exit=139 after 'probe: UNguarded') and 2 (fixed: '-> 0 (NO TRAP)', exit=0); fix.diff = the guard added at prop_number.c:379-384; pfix_number.c is the patched copy used in the rig.
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log: baseline '[*] control: guarded prop_number_integer_value(str) -> 0 (no trap)' then '[*] probe: UNguarded prop_number_unsigned(str) ...' followed by '--- baseline exit=139' (SIGSEGV on guard page); fixed variant prints '-> 0 (NO TRAP)' exit=0. Layout math in README.md (+40..43 vs 40-byte object).
PoC changes
Authored fresh (no seed): typeconf.c harness + pguard_object_impl.h (userland _PROP_POOL_GET/_PROP_POOL_PUT replaced by a page-guard allocator for deterministic OOB detection - guest base cc 8.3 has no usable ASAN runtime) + pfix_number.c (fix under test applied to a copy of the identical source).
Verified recommended fix
Add the prop_object_is_number() guard to prop_number_unsigned(), matching every sibling accessor (see fix.diff).
Verdict
prop_number_unsigned() (prop_number.c:379-384) is the only prop_number accessor missing the prop_object_is_number() guard its seven siblings have; on a type-confused argument it reads the pnv_is_unsigned bitfield at +40..43, i.e. 4 bytes entirely past the 40-byte struct _prop_string allocation (16-byte _prop_bool likewise). Deterministically proven on the same-source userland rig: with a guard-page heap, the guarded control accessor returns 0 without trapping while prop_number_unsigned((prop_number_t)string) SIGSEGVs on the guard page (exit 139), and the exact fix.diff (guard added) removes the trap (returns false, exit 0). Kernel impact today is none reachable: zero in-kernel callers outside libprop (only create-side, type-safe kern_udev.c:195,215), so this is filed as hardening/defense-in-depth - one future caller away from a kernel heap OOB read on user-influencible plist data.
No comments yet.