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

Kernel _PROP_ONCE_RUN cmpset-once has no completion barrier: first concurrent prop_number use races the interned-tree lazy init (lockmgr on mid-lockinit lock, rbt_ops still NULL)

Summary

In the kernel build the once macro is a bare cmpset: exactly one CPU runs the init but losers do NOT wait - _prop_number_lock can proceed to lockmgr() while lockinit is still overwriting lk_count/lk_lockholder (wiping an acquisition, breaking mutual exclusion on the global interned tree) and _prop_rb_tree_find can deref rbt_ops while still BSS-NULL (NULL indirect call -> panic). Userland safe (pthread_once) - kernel-only, same source. Rig-differentiated: pthread_once control + SYSINIT-warmup variant SURVIVE 5/5, the kernel-macro port SIGSEGVs 5/5 in _prop_rb_tree_find. Kernel first-use window closed at boot on this tree (classified not_reproduced in-kernel); DF-2225 filed the same macro shape on prop_dictionary - this is the number-tree instance with the distinct lockmgr-wipe consequence. SYSINIT-bootstrap-at-SI_SUB_PROP fix proposed (the same point that already creates the pool). DF-5674 twin check CLEAN (pot_lock registered, all free paths serialized).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5681 Β· 13 files
FileTypeDescriptionSize
README.md β€” 4.5 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw
race_once.c β€” 4.0 KB view raw
ksem_object_impl.h β€” 13.9 KB view raw
ksem_number.c β€” 14.9 KB view raw
build.sh β€” 75 B view raw
run.sh β€” 99 B view raw
build.log β€” 87 B view raw
run.log β€” 1.5 KB view raw
env.txt β€” 228 B view raw
fix.diff β€” 1.6 KB view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.4 KB view raw

DF-5681 β€” prop_number lazy-init once-race: kernel _PROP_ONCE_RUN has no completion barrier

What

sys/libprop/prop_number.c lazily initializes its interned-number registry (_prop_number_tree + _prop_number_tree_mutex) through _PROP_ONCE_RUN(_prop_number_init_once, _prop_number_init) at prop_number.c:182 (_prop_number_lock, reached on every number release via pot_lock) and prop_number.c:277 (_prop_number_alloc).

In the kernel build _PROP_ONCE_RUN is (prop_object_impl.h:302-303):

#define _PROP_ONCE_DECL(x)   static int x = 0;
#define _PROP_ONCE_RUN(x,f)  if (atomic_cmpset_int(&(x), 0, 1)) f()

This provides "exactly one CPU runs f()" but no completion barrier: every CPU that loses the cmpset falls through immediately and proceeds to use the data that f() is still initializing. _prop_number_init() (prop_number.c:169-176) does lockinit(&_prop_number_tree_mutex, ...) then _prop_rb_tree_init(&_prop_number_tree, ...). A losing CPU therefore:

  1. calls lockmgr(&_prop_number_tree_mutex, LK_EXCLUSIVE) on a lock that is mid-lockinit β€” lockinit() overwrites lk_count and lk_lockholder (sys/kern/kern_lock.c:1300-1307), wiping the loser's acquisition state, so mutual exclusion on the interned tree can be broken from birth; and
  2. calls _prop_rb_tree_find() (prop_number.c:284) on a tree whose rbt_ops is still the BSS-zero NULL β€” _prop_rb_tree_find unconditionally dereferences rbt->rbt_ops->rbto_compare_key (sys/libprop/prop_rb.c:90-93) β†’ NULL-pointer indirect call β†’ kernel panic.

Window: only the first-ever concurrent prop_number use system-wide (two CPUs entering _prop_number_alloc β€” or create+release β€” at the same instant, e.g. two simultaneously-serviced proplist ioctls or udev events before any other proplib number traffic). The window is the duration of lockinit+_prop_rb_tree_init (sub-microsecond), so this is a narrow, boot-time-flavored race: realistic worst case is a panic; the broken-mutex consequence would additionally allow two CPUs inside the rb-tree critical section (same corruption class as DF-5674), but that combination is considered theoretical.

The userland build is not affected: there _PROP_ONCE_RUN ispthread_once` (prop_object_impl.h:428-430), which does block losers until initialization completes. Same source, divergent macro β€” the defect is kernel-only.

Reproduce (rig β€” differential harness on the QEMU guest)

The guest's /usr/src/sys/libprop is md5-identical to the audited tree (db1cdaf5e03235d1a1c4a3e2c4a2720b). build.sh compiles two userland libprop variants from it:

  • prop5681_orig β€” stock sources (pthread_once). Control.
  • prop5681_ksem β€” identical sources except the userland once-macros are replaced by a faithful port of the _KERNEL cmpset-once (__sync_bool_compare_and_swap), and _prop_number_init carries a 50 ms window-widening usleep between lockinit and _prop_rb_tree_init (instrumentation only; it widens exactly the ordering window the kernel macro leaves open).

run.sh then runs:

  1. ./race_once (control) β€” 8 threads barrier-synchronized, each doing first-ever prop_number_create_integer + interning-invariant checks.
  2. ./race_once_ksem β€” same, against kernel-macro semantics.
  3. ./race_once_ksem warmup β€” same ksem library, but the once is completed single-threaded before the barrier: exactly what the proposed SYSINIT(SI_SUB_PROP) bootstrap fix does in-kernel.

Expected / observed (run.log, 2026-10-10)

variant result
control (pthread_once) SURVIVED
kernel-macro semantics SIGSEGV, 5/5 runs (exit 139) β€” losers reach _prop_rb_tree_find while the winner is still in _prop_number_init
fix emulation (once pre-completed) SURVIVED, 5/5 runs

Bottom line: with the kernel's once-macro semantics the first-use race is deterministically fatal in the rig; completing the once before first concurrent use (the SYSINIT fix) eliminates it. The running guest kernel itself was not crashed (its first-use window closed during boot long before login), so the kernel-binary manifestation is classified not_reproduced β€” the pack proves the mechanism.

Fix

fix.diff β€” bootstrap the once single-threaded at SI_SUB_PROP (the same SYSINIT point that already creates the number objcache pool, _PROP_POOL_INIT, prop_object_impl.h:273-281 β€” any first allocation necessarily comes after it, so the ordering assumption is already load-bearing in-tree). No hot-path cost; the lazy cmpset stays as a no-op backstop.

VERDICT.md
↓ download raw

DF-5681 β€” VERDICT

Status: not_reproduced (kernel binary) β€” mechanism proven deterministically in a faithful rig; defect certain from code inspection.

The finding

_PROP_ONCE_RUN in the kernel build (prop_object_impl.h:302-303) is a cmpset-once with no completion barrier:

#define _PROP_ONCE_RUN(x,f)  if (atomic_cmpset_int(&(x), 0, 1)) f()

prop_number.c runs it at :182 (_prop_number_lock, on the hot path of every number release via pot_lock) and :277 (_prop_number_alloc). The losing CPU of the cmpset does not wait for the winner's _prop_number_init() (prop_number.c:169-176) and can therefore:

  • lockmgr() a struct lock that is mid-lockinit β€” and lockinit overwrites lk_count/lk_lockholder (sys/kern/kern_lock.c:1300-1307), destroying the loser's acquisition β‡’ mutual exclusion on the interned number tree broken from birth (two CPUs may then concurrently mutate _prop_number_tree β€” the DF-5674 corruption class); and/or
  • reach _prop_rb_tree_find (prop_number.c:284) while _prop_number_tree.rbt_ops is still the BSS-zero NULL β€” _prop_rb_tree_find loads rbt->rbto_compare_key (sys/libprop/prop_rb.c:90-93) β‡’ NULL-pointer indirect call β‡’ panic.

The twin check requested for this file: prop_number does register pot_lock/pot_unlock (prop_number.c:84-85) and both prop_object_release (prop_object.c:1077-1097) and prop_object_release_emergency (prop_object.c:1017-1043) hold them across pot_free β‡’ the DF-5674 defect does not apply to numbers. The once-race is a different, kernel-only defect in the same lazy-init machinery: userland compiles _PROP_ONCE_RUN to pthread_once (prop_object_impl.h:428-430), which blocks losers until completion β€” so libprop.so is safe and only the kernel macro is broken.

Why not reproduced

The kernel manifestation requires winning a sub-microsecond window at the first-ever prop_number use system-wide. On the running guest that window closed during early boot (proplib numbers are created by udev/attach traffic long before login), and no unprivileged runtime action can re-open it. Crashing the guest kernel was therefore not possible; the classification follows the honest mapping (guest never panicked β‡’ not_reproduced).

What WAS demonstrated (run.log)

Rig on the QEMU guest (sources: guest /usr/src/sys/libprop, md5-identical to the audited sys/libprop β€” verified):

  1. Control β€” stock userland libprop (pthread_once): 8 threads, barrier-synchronized first-use, 512 values each, interning invariant checked: SURVIVED, exit 0.
  2. Kernel-macro semantics β€” same sources, userland once swapped for a faithful port of the _KERNEL cmpset-once, plus a 50 ms window-widening usleep inside _prop_number_init between lockinit and _prop_rb_tree_init (the exact ordering the kernel macro leaves unprotected): SIGSEGV 5/5 runs (exit 139) β€” losers proceed into _prop_rb_tree_find with rbt_ops == NULL while the winner is still initializing.
  3. Fix emulation β€” same ksem library, once completed single-threaded before first concurrent use (what the SYSINIT(SI_SUB_PROP) bootstrap does): SURVIVED 5/5, exit 0.

The differential (safe ↔ crash ↔ safe with only the once-completion property changed) isolates the defect to the missing completion barrier exactly as analyzed.

Fix validation

fix.diff bootstraps the once at SI_SUB_PROP β€” the same SYSINIT point that already creates the number objcache pool (_PROP_POOL_INIT, prop_object_impl.h:273-281), so "no consumer runs before it" is an in-tree guarantee the pool already relies on. Rig-level fix control (variant 3 above) is the emulation of exactly this fix and survives 5/5. A single-fix kernel rebuild was not performed (no kernel-memory- corruption reproduction to gate it); fix_status fixed is claimed at rig level + ordering-equivalence argument, noted accordingly.

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

Rig-level fix validation: baseline (kernel once-macro semantics) SIGSEGV 5/5; fix emulation (once completed before first concurrent use = exactly the SYSINIT bootstrap of fix.diff) survives 5/5 with interning invariant intact. Single-fix kernel rebuild not performed: no kernel memory-corruption reproduction gates it, and the fix's ordering guarantee (consumers run after SI_SUB_PROP) is the same in-tree guarantee the existing _PROP_POOL_INIT SYSINIT already relies on.

run.log section 3 (fix runs 1-5 exit=0, 'SURVIVED: interning invariant held') vs section 2 (ksem runs 1-5 exit=139); fix.diff adds the SI_SUB_PROP SYSINIT bootstrap to sys/libprop/prop_number.c.
↓ fix.diffper-fix-DF-5681

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log lines: 'control exit=0' / '--- ksem run1..5 exit=139' (SIGSEGV with kernel once-macro semantics) / '--- fix run1..5 exit=0'; VERDICT.md traces the macro at prop_object_impl.h:302-303 vs :428-430 and the crash path prop_number.c:277->283->284 + prop_rb.c:90-93; fix.diff = SI_SUB_PROP SYSINIT bootstrap.

PoC changes

Authored fresh (no seed): race_once.c harness; ksem_object_impl.h (userland once swapped for a faithful port of the kernel cmpset-once) and ksem_number.c (50ms window-widening usleep inside _prop_number_init, instrumentation only) generated from the md5-identical guest sources.

Verified recommended fix

Bootstrap the lazy init single-threaded at SI_SUB_PROP via SYSINIT so the cmpset-once can never race first use (see fix.diff); alternatively give the kernel _PROP_ONCE_RUN real completion semantics.

Verdict

Kernel-only lazy-init race: _PROP_ONCE_RUN compiled for the kernel (prop_object_impl.h:302-303) is a cmpset-once with no completion barrier, so the first-ever concurrent prop_number use lets the cmpset loser reach lockmgr()/_prop_rb_tree_find() while the winner is still inside _prop_number_init() (prop_number.c:169-176) - lockinit overwrites the loser's lock state (kern_lock.c:1300-1307) and rbt_ops may still be NULL (prop_rb.c:90-93 NULL indirect call -> panic). Userland is safe (pthread_once). The guest kernel itself was NOT crashed (the sub-microsecond first-use window closed during boot), hence not_reproduced; the rig proves the mechanism deterministically: stock userland libprop survives 8-thread barrier first-use, the same sources with the kernel cmpset-once port SIGSEGV 5/5 in _prop_rb_tree_find, and pre-completing the once (the SYSINIT fix) survives 5/5. Realistic kernel worst case is a boot-time-flavored panic; broken-mutex rb-tree corruption is theoretical. Fix: bootstrap the once at SI_SUB_PROP (same point the number objcache pool is created), validated at rig level.