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

prop_array_set/prop_array_remove enforce index bounds only via INVARIANTS-gated assert: stock-kernel panic (reproduced live), silent heap OOB write + prop_object_release of an OOB-read pointer on non-INVARIANTS kernels

Summary

Both writers validate idx with _PROP_ASSERT only (KKASSERT - compiled to nothing without INVARIANTS): stock kernels panic on any out-of-range index; non-INVARIANTS production kernels read an 8-byte pointer OOB, write a kernel pointer OOB at an arbitrary 32-bit index, and call prop_object_release on the OOB-read value (refcount decrement at attacker-shaped address + pot_free dispatch on a fake object; remove additionally underflows count on empty arrays). prop_array_get shows the intended real-check contract the writers fail to mirror. No in-tree kernel caller forwards a plist-derived index today (all audited) - latent, one API misuse away in the kernel's untrusted-plist container library. Proven live: KLD triggers panic the stock guest at :648/:701; NDEBUG rig SIGSEGVs inside prop_object_release on the OOB-read garbage (wild-release primitive self-demonstrating); guard-page rig faults at exactly the predicted offset. real-check fix validated on a full kernel rebuild (both modules complete, 12/12 rig battery clean).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5678 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.3 KB ↓ raw
VERDICT.md β€” 7.2 KB ↓ raw
df5678_userland.c β€” 5.3 KB view raw
guardalloc.h β€” 2.7 KB view raw
df5678_mod.c β€” 2.7 KB view raw
build.sh β€” 219 B view raw
run.sh β€” 360 B view raw
run.userland.log β€” 2.3 KB view raw
run.userland.fixed.log β€” 2.2 KB view raw
panic.set.txt β€” 776 B view raw
panic.remove.txt β€” 755 B view raw
fixtest.console.txt β€” 401 B view raw
build.baseline.log β€” 520 B view raw
build.fixed.log β€” 520 B view raw
build.tail.txt β€” 55 B view raw
env.txt β€” 678 B view raw
fix.diff β€” 1.1 KB view raw
verdict.json β€” 5.6 KB view raw

DF-5678 β€” prop_array_set/prop_array_remove bounds enforced only by INVARIANTS-gated assert

sys/libprop/prop_array.c β€” pass 2 (GLM 5.3 re-audit).

The defect

prop_array_set() and prop_array_remove() validate their index argument only via _PROP_ASSERT:

  • sys/libprop/prop_array.c:648 β€” _PROP_ASSERT(idx < pa->pa_count); (prop_array_set), immediately followed by OOB read opo = pa->pa_array[idx] (:650), OOB write pa->pa_array[idx] = po (:654), and prop_object_release(opo) on the OOB-read value (:657).
  • sys/libprop/prop_array.c:701 β€” same assert (prop_array_remove), followed by OOB read po = pa->pa_array[idx] (:709) and prop_object_release(po) of the garbage pointer (:719).

In the kernel _PROP_ASSERT == KKASSERT (sys/libprop/prop_object_impl.h:258), which is compiled in only when options INVARIANTS is set (sys/sys/systm.h:94-122):

  • stock X86_64_GENERIC (has INVARIANTS): kernel panic β€” reproduced live.
  • any kernel built without INVARIANTS (a fully supported production configuration): the check is compiled to nothing and both functions perform a silent heap OOB write of a kernel pointer at an arbitrary 32-bit index plus prop_object_release() of an out-of-bounds-read pointer (arbitrary-pointer refcount decrement / potential free).

Contrast prop_array_get() at :591, which does a real if (idx >= pa->pa_count) bounds check β€” the intended contract the two writers fail to mirror.

No current in-tree kernel caller passes an attacker-derived index (audited: dm_ioctl.c fixed indices 0..2 are provably safe because of the idx == pa_count append design at :643; dm_ioctl.c:649 loops over prop_array_count; vfs_quota.c/kern_udev.c use add/iterator only) β€” so the kernel reachability today is latent, one API misuse away. proplib is the kernel's untrusted-plist container library, which is why this is filed.

Reproduce (evidence classes: same-source userland differential + KLD)

On the audit guest (source snapshot, /usr/src/sys/libprop present):

scp df5678_userland.c guardalloc.h df5678_mod.c build.sh run.sh fix.diff dfbsd:/tmp/
vm.sh run_root 'mkdir -p /tmp/df5678 && mv /tmp/df5678_* /tmp/guardalloc.h /tmp/build.sh /tmp/run.sh /tmp/fix.diff /tmp/df5678/ 2>/dev/null; cd /tmp/df5678 && sh build.sh'
vm.sh run_root 'cd /tmp/df5678 && sh run.sh'          # userland differential
vm.sh run_root 'kldload -v /tmp/df5678/kmod_set/df5678.ko'   # PANIC at :648
# ... reset, then:
vm.sh run_root 'kldload -v /tmp/df5678/kmod_rem/df5678rem.ko' # PANIC at :701

Expected output (baseline)

  • df5678_assert set β†’ Assertion failed: (idx < pa->pa_count), function prop_array_set, file prop_array.c, line 648 exit 134 (stock-kernel analog).
  • df5678_ndebug set β†’ SIGSEGV in prop_object_release on an OOB-read garbage pointer (wild release), exit 139 (non-INVARIANTS-kernel analog).
  • df5678_guard set β†’ SIGSEGV with si_addr in the guard page at exactly pa_array[512]; the remove fault address differs by exactly (600-512)*8 = 704 bytes β†’ the touched slots are pa_array[512]/[600].
  • KLD loads β†’ panic: assertion "idx < pa->pa_count" failed in prop_array_set/prop_array_remove at .../prop_array.c:648/:701 (panic.set.txt / panic.remove.txt).

After fix.diff (validated)

  • userland battery: every variant/scenario exit 0; set(512) returns 0 (false); remove(600) no-ops; guard rig raises no fault (run.userland.fixed.log).
  • full make -j8 nativekernel + installkernel + reboot: both KLDs load, set(5) returned 0, count=2 / remove(7) returned, count=2, module completed without panic (fixtest.console.txt).

Files

  • df5678_userland.c β€” 3-variant differential harness
  • guardalloc.h β€” rig-only guard-page allocator (patches the rig copy of prop_object_impl.h; sys/ is never modified)
  • df5678_mod.c β€” kernel trigger modules (MODE_SET / MODE_REM)
  • build.sh, run.sh β€” exact runnable commands
  • run.userland.log, run.userland.fixed.log β€” baseline / fixed batteries
  • panic.set.txt, panic.remove.txt β€” stock-kernel panics (serial console)
  • fixtest.console.txt β€” fixed-kernel module runs, no panic
  • build.baseline.log, build.fixed.log β€” rig build logs
  • env.txt β€” guest environment (fixed-kernel phase; baseline uname in VERDICT.md)
  • fix.diff β€” git-apply-able unified diff (validated)
VERDICT.md
↓ download raw

DF-5678 VERDICT β€” reproduced (kernel panic proven live; silent OOB write + wild release proven on the same source with the assert compiled out)

File: sys/libprop/prop_array.c (pass 2, GLM 5.3) Baseline guest kernel: DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (INVARIANTS on β€” verified: strings /boot/kernel/kernel | grep -c 'assertion "%s" failed' == 3, and both panics below). Fixed guest kernel: #1: Fri Oct 9 23:00:04 UTC 2026 (full make -j8 nativekernel KERNCONF=X86_64_GENERIC + installkernel of fix.diff).

Root cause (path:line)

  • sys/libprop/prop_array.c:648 β€” _PROP_ASSERT(idx < pa->pa_count); in prop_array_set() is the ONLY index bound before:
  • :650 opo = pa->pa_array[idx]; β€” OOB read (8-byte kernel pointer)
  • :654 pa->pa_array[idx] = po; β€” OOB write (kernel pointer at arbitrary 32-bit index)
  • :657 prop_object_release(opo); β€” release of the OOB-read pointer (refcount decrement at garbage+offsetof(po_refcnt); on 0 β†’ pot_free dispatch on a fake object)
  • sys/libprop/prop_array.c:701 β€” same in prop_array_remove(), followed by the OOB read at :709 and prop_object_release(po) of garbage at :719; the pa->pa_count-- at :714 additionally underflows when count==0.
  • _PROP_ASSERT is KKASSERT in the kernel (sys/libprop/prop_object_impl.h:258) and KKASSERT is active only with options INVARIANTS (sys/sys/systm.h:94-122 β€” the #else at :116-118 defines it to do {} while(0)). Stock X86_64_GENERIC has INVARIANTS (sys/config/X86_64_GENERIC:56); kernels built without it β€” a fully supported, common production configuration β€” compile the bound check away.
  • The intended contract is visible in the same file: prop_array_get() does a real check at :591 (if (idx >= pa->pa_count) goto out;).

What was reproduced, and how

1. Stock-kernel analog + live kernel proof (assert live)

  • Same-source userland rig (assert build): set aborts with Assertion failed: (idx < pa->pa_count), function prop_array_set, file prop_array.c, line 648 (exit 134); remove aborts at line 701 (run.userland.log, section A).
  • Live kernel: kldload df5678.ko (calls prop_array_set(pa,5,po) on a count=2 array inside MOD_LOAD) β†’ panic: assertion "idx < pa->pa_count" failed in prop_array_set at /usr/src/sys/libprop/prop_array.c:648 with the full backtrace through df5678_modevent (panic.set.txt). After reset, kldload df5678rem.ko (prop_array_remove(pa,7)) β†’ panic at prop_array.c:701 (panic.remove.txt). This proves the defective code is compiled into and executes in the running stock kernel.

2. Non-INVARIANTS-kernel analog (assert compiled out)

  • NDEBUG rig (plain libc malloc): set(512) on a count=capacity=510 array β†’ SIGSEGV si_addr=0x89736c6162 β€” the fault is inside prop_object_release(opo) at :657 dereferencing the garbage pointer read out of bounds at :650: the wild-release primitive demonstrating itself. remove(600) β†’ SIGSEGV si_addr=0x0 (release of an OOB-read NULL-ish value). (run.userland.log, section B; the handler's fixed text says "guard page" β€” in section B the faults are wild-pointer derefs, in section C they are true guard-page hits.)
  • Guard rig (NDEBUG + guard-page allocator around every _PROP_* allocation; array backing store of 5108+16 == 4096 bytes ends flush with a PROT_NONE page): set(512) faults at si_addr=0x8004f3010, remove(600) at si_addr=0x8004f32d0. The addresses differ by exactly 0x2c0 = 704 = (600-512)8** β€” i.e. the faulting accesses are precisely at pa_array[512] and pa_array[600], the slots the source analysis predicts (:650/:654 and :709). No sanitizer needed.

3. Reachability (honest scope)

No in-tree kernel caller passes an attacker-derived index today:

So the kernel impact today is latent: one future (or currently unknown) caller that forwards a plist-derived index β€” the exact use pattern this container library exists for β€” turns this into a stock-kernel panic (INVARIANTS) or, on non-INVARIANTS kernels, an attacker-shaped heap OOB write plus arbitrary-pointer release (refcount decrement β†’ UAF). The primitive is the reason this is Medium and not Low.

4. Fix validation (full kernel rebuild)

  • fix.diff adds real bounds checks mirroring prop_array_get: idx > pa->pa_count β†’ goto out (rv=false) in prop_array_set, and idx >= pa_count β†’ unlock+return in prop_array_remove. git-apply and patch(1) clean against the read-only tree; applied only inside the guest's /usr/src copy.
  • Patched-source userland rig: all variants/scenarios exit 0; set(512) returns 0; remove(600) no-ops; guard rig raises no fault (run.userland.fixed.log). Controls and the legal idx == count append path are unchanged.
  • make -j8 nativekernel KERNCONF=X86_64_GENERIC + installkernel + reboot into kernel #1 (23:00:04): both trigger modules load and print set(5) returned 0, count=2 / remove(7) returned, count=2 / module completed without panic (fixtest.console.txt). Baseline panics: panic.set.txt / panic.remove.txt. Bad behavior gone in both builds.
  • The kernel-build log was lost to the guest's /tmp-cleaning reboot; the kernel identity (#1: Fri Oct 9 23:00:04 vs baseline #0: Thu Jul 2), env.txt, and the before/after behavioral evidence carry the validation.

Differential-harness notes

  • The segv_handler message text ("guard page after prop_array backing store") is generic for all three binaries; it is literally true for the guard rig (C) and means "wild pointer deref" for the NDEBUG rig (B).
  • df5678_ndebug set faulting instead of "surviving silently" is a stronger result than the README predicted: the wild release at :657/:719 fires immediately in this heap layout; on other layouts the corruption is silent until later (that silence is exactly the non-INVARIANTS production risk).

Verdict

reproduced β€” both halves of the differential (assert-live panic at prop_array.c:648/:701 in the running stock kernel; assert-compiled-out OOB read/write + release-of-garbage with slot-exact fault localization on the same source), and fixed on a full kernel rebuild (panics gone, bounds enforced, legal paths unchanged).

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.diff applied inside guest /usr/src, full make -j8 nativekernel KERNCONF=X86_64_GENERIC + installkernel + reboot into #1 (Fri Oct 9 23:00:04): baseline panics at prop_array.c:648/:701 are gone β€” both trigger modules load and complete without panic (set(5) returns 0/false, remove(7) no-ops). Patched-source userland rig: all 12 variant/scenario cells exit 0, no asserts, no guard faults, legal in-bounds and idx==count append paths unchanged.

fixtest.console.txt (both modules 'completed without panic' on fixed kernel); run.userland.fixed.log (clean battery); panic.set.txt/panic.remove.txt (baseline); env.txt (fixed-kernel uname, kldstat)
↓ fix.diffper-fix-DF-5678

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.set.txt + panic.remove.txt (stock-kernel KKASSERT panics at prop_array.c:648/:701 with backtraces through df5678_modevent); run.userland.log (assert rig aborts naming :648/:701; NDEBUG rig SIGSEGVs in prop_object_release on OOB-read garbage si_addr=0x89736c6162; guard rig si_addr delta exactly 704 bytes == (600-512)*8 proving slot-exact OOB); run.userland.fixed.log (all variants exit 0, set returns 0, remove no-ops); fixtest.console.txt (fixed kernel #1: both modules load, 'module completed without panic'); fix.diff

PoC changes

Harnesses written fresh for this file: 3-variant same-source userland differential (assert/NDEBUG/guard β€” the guard variant needed a rig-only guarded allocator since the guest base gcc has no usable ASAN, following DF-5676's guard-page pattern), plus two KLD trigger modules. Repairs along the way: MOD_QUIET does not exist on DragonFly (compile error); bsd.kmod.mk drops CFLAGS+= so module modes must be baked into per-variant source copies (first rem-module silently ran SET code); unused-variable -Werror in the REM variant; /tmp is wiped on guest reboots (re-push needed each boot).

Verified recommended fix

Replace the two _PROP_ASSERT bounds in prop_array_set/prop_array_remove with real checks mirroring prop_array_get (see fix.diff).

Verdict

prop_array_set()/prop_array_remove() enforce the idx<pa_count bound only via _PROP_ASSERT==KKASSERT, which is INVARIANTS-gated (sys/sys/systm.h:94-122): the running stock X86_64_GENERIC kernel panics at prop_array.c:648/:701 (both reproduced live via KLD trigger modules, full backtraces in panic.set.txt/panic.remove.txt), while the same source with the assert compiled out (-DNDEBUG analog of a supported non-INVARIANTS kernel build) performs the OOB read at :650/:709, the OOB write at :654, and prop_object_release() of the OOB-read garbage at :657/:719 β€” the wild release fired on its own in the NDEBUG rig (SIGSEGV on a heap-garbage pointer), and the guard rig localized the faults to exactly pa_array[512]/pa_array[600] (delta 704 == (600-512)*8). No in-tree kernel caller currently forwards an attacker-derived index (dm's fixed 0..2 set-indices are provably safe via the idx==count append at :643; other callers use count-bounded get/add/iterator), so kernel reachability is latent β€” one plist-index-forwarding caller away from stock-kernel DoS or, on non-INVARIANTS kernels, attacker-shaped heap corruption. Fixed and validated on a full nativekernel rebuild: panics gone, set() returns false, remove() no-ops, all legal paths unchanged.