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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5682 Β· 13 files
FileTypeDescriptionSize
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_copy prop_number.c:364
  • prop_number_size prop_number.c:396
  • prop_number_integer_value prop_number.c:432
  • prop_number_unsigned_integer_value prop_number.c:450
  • prop_number_equals prop_number.c:463
  • prop_number_equals_integer prop_number.c:477
  • prop_number_equals_unsigned_integer prop_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 + stock prop_number.c
  • prop5682_fix β€” guard-page allocator + prop_number.c with the prop_object_is_number() guard added (pfix_number.c = the exact fix.diff under test)

typeconf.c creates one prop_string_t, then:

  1. control β€” prop_number_integer_value((prop_number_t)str): the guarded sibling, must return 0 without trapping;
  2. 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);
 }
VERDICT.md
↓ download raw

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: rg over sys/ finds zero callers of prop_number_unsigned() outside libprop itself; the only kernel prop_number consumers are prop_number_create_integer / prop_number_create_unsigned_integer in 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/libprop builds the same source into libprop.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).

  1. 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.
  2. 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.
  3. Fix β€” same rig with pfix_number.c (the exact fix.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

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: 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.
↓ fix.diffper-fix-DF-5682

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.