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

Use-after-free of shared udev event dictionary in udev_event_externalize (multi-reader)

Field Value
ID DF-0055
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-416 Use After Free
File sys/kern/kern_udev.c
Lines 571 (release), 540 (clean predicate), 566 (re-deref)
Area kern
Confidence certain
Discovered 2026-06-29
Reported pending

Summary

The udev event queue replicates every event to all openers via a per-softc marker system. udev_event_externalize() calls prop_object_release(ev->ev.ev_dict) (:571) the first time any reader externalizes an event, freeing the shared proplib dictionary while the event remains on udev_evq. The field is never NULLed, so udev_clean_events_locked() (:540, predicate ev_dict != NULL) treats the freed event as still-live and refuses to reap it. The next reader's marker-walk finds the same event (dangling, non-NULL ev_dict, so the skip loop does not skip it) and calls udev_event_externalize() again, which passes the freed pointer into prop_dictionary_set→prop_object_retain(freed) (:566) — a use-after-free on a kernel proplib object. Gated by /dev/udev 0600 root:wheel (:1039-1041).

Root cause

sys/kern/kern_udev.c:

/* udev_event_externalize :566-571 */
if (prop_dictionary_set(dict, "evdict", ev->ev.ev_dict) == false) { ... }
prop_object_release(ev->ev.ev_dict);   /* :571  frees the shared dict; never NULLed */

/* udev_clean_events_locked :539-540 */
while ((ev = TAILQ_FIRST(&udev_evq)) && ev->ev.ev_dict != NULL) { /* dangling != NULL -> not reaped */

After the first reader's externalize: the temp dict's retain (+1) and the event's release (-1) leave the dict at refcount 1 (held by temp); when temp is released (:575), the dict hits 0 and is freed. ev->ev.ev_dict is now dangling. The second reader's externalize (:566) passes the dangling pointer to prop_dictionary_set β†’ UAF.

Threat model & preconditions

  • Attacker position: root or wheel (via /dev/udev 0600).
  • Privileges gained or impact: kernel heap corruption β€” reliable local kernel panic (DoS); with heap grooming, potential further exploitation (the freed proplib object is reused by the allocator). No direct confidentiality/integrity impact beyond corruption.
  • Required config or capabilities: /dev/udev access (root/wheel); device attach/detach activity generating events.
  • Reachability: two readers (two fds/processes) on /dev/udev, both initiated, reading while events are generated.

Proof of concept

PoC source: findings/poc/DF-0055/udev_uaf.c

Build & run (root/wheel, disposable VM)

cc -o udev_uaf findings/poc/DF-0055/udev_uaf.c
./udev_uaf    # trigger device events (USB/disk attach/detach) in parallel

Expected output

Kernel panic (heap corruption) when the second reader hits an event whose dict the first reader already externalized.

Impact

Kernel UAF reachable by root/wheel via two concurrent readers + device events. Medium (UAF = potential corruption/DoS; root/wheel prerequisite bounds the privilege gain).

Move the final prop_object_release to udev_clean_events_locked (which owns the single release + NULL), and remove the release from udev_event_externalize so each reader borrows the dict (net-zero retain):

--- a/sys/kern/kern_udev.c
+++ b/sys/kern/kern_udev.c
@@ -540 +540,3 @@
           ev->ev.ev_dict != NULL) {
+       prop_object_release(ev->ev.ev_dict);
+       ev->ev.ev_dict = NULL;
        TAILQ_REMOVE(&udev_evq, ev, link);
@@ -571
-   prop_object_release(ev->ev.ev_dict);

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/kern_udev.c.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0055 Β· 18 files
FileTypeDescriptionSize
udev_uaf.c trigger-source two-forked /dev/udev readers + tap create/destroy event generator; deterministic UAF trigger 5.6 KB view raw
build.sh build-script cc -O2 -o udev_uaf udev_uaf.c 263 B view raw
run.sh run-script ./udev_uaf (root only, disposable VM) 576 B view raw
build.log build-log final successful PoC build output 718 B view raw
run.log run-log RUN 1 (decisive baseline): double-free panic 'memory chunk is already free' 1.5 KB view raw
run.2.log run-log RUN 2 (baseline confirmation): refcount-underflow panic 'ocnt != 0' 1.4 KB view raw
run.3.log run-log RUN 3 (baseline confirmation): identical to RUN 2 1.3 KB view raw
run.baseline.log run-log Phase-8 baseline re-reproduction on unpatched #0 (panic, guest DOWN) 1.5 KB view raw
panic.txt panic-signature all baseline serial-console panic dumps from dfbsd-qemu/boot.log 3.8 KB view raw
env.txt environment uname, cc version, /dev/udev perms, securelevel, kldstat 1.4 KB view raw
VERDICT.md verdict full narrative: mechanism walkthrough, exploit-chain assessment, PoC changes, fix validation 11.1 KB ↓ raw
fix.diff suggested-fix git-apply-able: move release to clean_events + NULL field, remove from externalize 1.1 KB view raw
README.md readme build/run/expected + bug summary 3.5 KB ↓ raw
fix_build.log build-log Phase-8 full nativekernel build output of the single-fix kernel (rc=0, 35573 lines) 5.6 MB ↓ download
fix_run.log run-log Phase-8 PoC on patched #1 kernel: 3/3 runs no-panic, boot.log+dmesg clean 5.2 KB view raw
dmesg.txt dmesg clean dmesg excerpt on patched kernel after 3 runs (no slab/objcache warnings) 237 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected + bug summary
↓ download raw

DF-0055 β€” PoC (REPRODUCED)

udev_uaf.c β€” deterministic trigger for the use-after-free of the shared udev event dictionary via the multi-reader event-replication path.

Verdict

REPRODUCED. 3/3 fresh-VM runs panicked the DragonFlyBSD master DEV kernel in the exact cited path (udev_dev_read β†’ udev_event_externalize β†’ prop_object_release). Two distinct panic signatures (malloc double-free; proplib refcount-underflow), same root cause. See VERDICT.md and panic.txt.

The bug

The udev event queue replicates every event to all openers via per-softc marker nodes spliced into the shared udev_evq TAILQ. udev_event_externalize() (sys/kern/kern_udev.c:548) at :571 calls prop_object_release(ev->ev.ev_dict) on the first reader to externalize an event, freeing the shared proplib dict while the event remains on udev_evq. The field is never NULLed, so:

  • udev_clean_events_locked() (:535, predicate ev_dict != NULL at :540) leaves the event in the queue (it is stopped at the trailing reader's marker, which has a NULL ev_dict).
  • The next reader's marker-walk (:838-840) finds the same event (dangling non-NULL ev_dict passes the skip loop), calls udev_event_externalize() again, which at :566 passes the freed pointer to prop_dictionary_set() β†’ prop_object_retain(freed) β†’ UAF write; the subsequent :571 release then triggers a double-free / refcount underflow.

The sequence is deterministic (udev_lk serializes the two reads; a reader cannot advance another reader's marker, so reader 2's marker is guaranteed to still precede the event).

Privilege

/dev/udev is 0600 root:wheel (kern_udev.c:1039-1041). Trigger must run as root (uid 0). maxx (uid 1001, not in wheel) cannot open it. Realistic impact: reliable local kernel panic (DoS) from root β€” matching the finding's Medium / CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H.

Build & run (root, disposable VM)

./build.sh         # cc -O2 -o udev_uaf udev_uaf.c
./run.sh           # ./udev_uaf   (panics a vulnerable kernel in ~1-2s)

Expected output

Bug present β€” kernel panic (captured on the serial console / dfbsd-qemu/boot.log):

panic: memory chunk 0xfffff80067b36860 is already free!
chunk_mark_free() at chunk_mark_free+0xae
_kfree() at _kfree+0x262
_prop_dictionary_free() at _prop_dictionary_free+0xe0
prop_object_release() at prop_object_release+0xfd
udev_dev_read() at udev_dev_read+0x14f

or the equivalent refcount-underflow form:

panic: assertion "ocnt != 0" failed in prop_object_release at .../prop_object.c:1085

Bug fixed β€” runs ~12s, prints no panic after 80 create/destroy cycles, exits 0.

Trigger mechanism

The PoC forks two readers, each opening /dev/udev (own softc/marker) and blocking in read() (which auto-inserts both markers at TAILQ_HEAD). The parent then runs ifconfig tap<N> create/destroy in a loop β€” each make_dev/destroy_dev on tap_ops generates a UDEV_EVENT_ATTACH/_DETACH via udev_event_insert (sys/vfs/devfs/devfs_core.c:1428,1451; sys/net/tap/if_tap.c:289,491). The first event the second reader reaches fires the UAF.

Fix

See fix.diff (matches the finding's proposal): move the prop_object_release from udev_event_externalize:571 into udev_clean_events_locked (release + NULL before TAILQ_REMOVE), so the shared dict is released exactly once when the event is reaped (after all readers have passed it), and each reader merely borrows it.

VERDICT.md verdict full narrative: mechanism walkthrough, exploit-chain assessment, PoC changes, fix validation
↓ download raw

DF-0055 β€” VERDICT

Verdict: REPRODUCED (deterministic kernel panic / local DoS; root-only trigger).

3/3 fresh-VM runs panicked in the exact cited path (udev_dev_read β†’ udev_event_externalize β†’ prop_object_release). Two distinct panic signatures (double-free caught by the malloc layer; refcount-underflow caught by the proplib _PROP_ASSERT) β€” both are direct consequences of the same use-after-free of the shared udev event dictionary.


Mechanism (every hop cited path:line)

The udev event queue (udev_evq, a TAILQ) replicates every event to all openers via a per-softc marker node spliced into the shared queue. Each reader walks its own marker forward; an event is reaped only once every reader's marker has advanced past it.

  1. Event enqueue β€” udev_event_insert() sys/kern/kern_udev.c:501 prop_dictionary_copy()s the dict (refcount = 1) and stores it in ev->ev.ev_dict (:512), then TAILQ_INSERT_TAIL(&udev_evq, ev, link) (:516).

  2. Reader read path β€” udev_dev_read() :811 finds the next event after the reader's marker, skipping NULL-dict nodes (:838-840), and calls udev_event_externalize(ev) (:842).

  3. The bug β€” udev_event_externalize() :548:

c :566 prop_dictionary_set(dict, "evdict", ev->ev.ev_dict) -> prop_object_retain(ev_dict) /* refcount 1 -> 2 */ :571 prop_object_release(ev->ev.ev_dict); /* refcount 2 -> 1 */ :575 prop_object_release(dict); /* temp dict: releases its "evdict" child -> refcount 1 -> 0 -> FREED */

After reader 1 finishes, ev->ev.ev_dict is a dangling, non-NULL pointer and the event is still on udev_evq. The field is never NULLed.

  1. Why the event is not reaped between readers β€” udev_clean_events_locked() :535 reaps from TAILQ_FIRST while ev->ev.ev_dict != NULL (:540). The loop stops at the first NULL-dict node. The trailing reader's marker (a struct udev_event_kernel whose ev_dict is NULL, inserted at :829) sits ahead of the just-read event in the other reader's view, so the clean loop halts at that marker and leaves the dangling event in place. Concretely: reader 1 cannot move reader 2's marker, so the event is provably still queued when reader 2 next reads.

  2. The UAF β€” reader 2's udev_dev_read() finds the same event (dangling non-NULL ev_dict passes the :839 skip loop and the :540 predicate), calls udev_event_externalize() again, which at :566 passes the freed pointer to prop_dictionary_set() -> prop_object_retain(freed) (prop_object.c:992, atomic_inc_32_nv(&po->po_refcnt) β€” a write to freed memory). The subsequent prop_object_release() at :571 then drives the stale refcount to 0 and calls _prop_dictionary_free() -> _kfree() on memory the allocator already considers free.

This is deterministic, not a tight race: udev_lk serializes the two reads, and reader 2's marker is guaranteed to still precede the event because only reader 2 can advance its own marker.

Evidence

See panic.txt for all three serial-console panic dumps. The decisive stack frames in every run are:

udev_dev_read()           <- the reader's externalize call site (kern_udev.c:842)
prop_object_release()     <- :571 release of ev->ev.ev_dict
_prop_dictionary_free()
_kfree() -> chunk_mark_free()  <- "memory chunk ... is already free!"  (RUN 1)
   -- or --
prop_object_release(): _PROP_ASSERT(ocnt != 0) at prop_object.c:1085    (RUNs 2,3)

Both signatures name udev_dev_read as the entry point β€” i.e. the panic is this bug, not an unrelated one.

Privilege / threat model (matches finding)

  • /dev/udev is 0600 root:wheel (kern_udev.c:1039-1041); maxx (uid 1001, not in wheel) gets Permission denied and cannot trigger this. Confirmed on the guest.
  • This is a root/wheel-local trigger. Realistic impact ceiling: reliable kernel panic (local DoS).
  • CVSS 3.1: AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H (Medium), matching the finding.

Exploit chain assessment (memory-corruption class)

The primitive is a UAF + double-free on a kernel prop_dictionary object, with the freed pointer immediately handed to prop_object_retain/prop_object_release, which dereference po->po_refcnt and po->po_type from the freed memory. On a non-INVARIANTS kernel the proplib object (struct _prop_dictionary, allocated via prop_dictionary_create β†’ kmalloc) lives in a general kmalloc bucket and could in principle be reclaimed with a victim object containing attacker-controlled bytes; the subsequent pot_free/pot_lock indirect calls through po->po_type would then be hijackable function-pointer targets.

However, two facts bound the realistic ceiling at DoS for this finding, and I did not develop a full LPE chain:

  1. The trigger already requires root. A root attacker can already kldload arbitrary code, write /dev/mem (when securelevel permits), or reboot the box β€” so "root β†’ kernel code execution" is not a meaningful privilege-escalation boundary, and the finding correctly rates the impact as DoS.
  2. The demonstrated panics (double-free / refcount-underflow) fire on the very first event the second reader touches, before any attacker has a chance to reclaim the freed object β€” so without first defeating the allocator's double-free detection (or racing the reclamation before prop_object_release re-derives the type), the bug expresses as a panic, not a controlled write.

A determined attacker who could arrange a reclaimed victim object in the same slab between the two reads (the udev_lk serialization leaves a window while reader 1 holds the lock releasing the dict and reader 2 blocks acquiring it) might convert this to a controlled kernel write, but the root prerequisite makes the effort disproportionate to the gain. The honest, defensible classification is reliable local kernel DoS from root, exactly as filed.

PoC changes (vs. the initial seeded source)

The seeded udev_uaf.c was a sketch: it opened two fds and read in a busy loop with usleep, but (a) never generated any device events, so the readers would block forever on an empty queue, and (b) put both fds in one process β€” which works (each open gets its own softc/marker) but makes the deterministic nature non-obvious. I rewrote it to:

  • fork() two reader children, each open("/dev/udev") (own softc/marker) and block in read() β€” auto-initiating both markers at TAILQ_HEAD before any event exists.
  • Parent waits for both to be ready, then generates a stream of udev events via ifconfig tap<N> create / destroy (each make_dev/ destroy_dev on tap_ops calls udev_event_attach/_detach β†’ udev_event_insert; confirmed at sys/net/tap/if_tap.c:289,491 and sys/vfs/devfs/devfs_core.c:1428,1451).
  • The deterministic UAF fires on the first event the second reader reaches; 80 iterations guarantee it.

One compile fix: a stray /* temp */ inside the header block comment prematurely closed the outer comment (fixed to (temp dict)).

Build: cc -O2 -o udev_uaf udev_uaf.c (clean, no deps). Run: ./udev_uaf as root on a disposable VM.

Authored in fix.diff (standalone, git apply-able, verified with git apply --check). Matches the finding markdown's proposal:

  1. In udev_clean_events_locked() (:535), add prop_object_release(ev->ev.ev_dict); ev->ev.ev_dict = NULL; before the existing TAILQ_REMOVE/objcache_put β€” so the shared dict is released exactly once, when the event is reaped (after all readers have advanced past it).
  2. Remove the prop_object_release(ev->ev.ev_dict) at udev_event_externalize():571, so each reader merely borrows the dict (the temp dict's set/release is net-zero on ev_dict).

Net effect on the correct path: dict refcount is 1 (held by the event) for the life of the event; each reader's externalize momentarily bumps it to 2 and back to 1; the final reader's clean reap drops it 1β†’0 and frees it. No dangling pointer, no double-free.


Fix validation (Phase 8 β€” built & booted the single-fix kernel)

Outcome: FIXED. Clean before/after on identical hardware, identical PoC.

Before (unpatched #0 baseline)

  • Kernel: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026, sha256(/boot/kernel/kernel) = 5dc83dac...22ad.
  • Re-ran the exact ./udev_uaf trigger as root: deterministic kernel panic on the first reader-2 event. Guest went DOWN. Serial console: panic: assertion "ocnt != 0" failed in prop_object_release at /usr/src/sys/libprop/prop_object.c:1085 cpuid = 1 prop_object_release() at prop_object_release+0x277 prop_object_release() at prop_object_release+0x277 udev_dev_read() at udev_dev_read+0x162 Same signature as the prior session's 3/3 runs (double-free form on run 1, refcount-underflow form on runs 2-3). See run.baseline.log and panic.txt.

Build the single-fix kernel

  • vm.sh reset with-src β†’ clean source + #0.
  • fix.diff applied to /usr/src with patch -p1: both hunks succeeded (Hunk #1 succeeded at 538. Hunk #2 succeeded at 575.). Verified the patched source: udev_clean_events_locked now releases + NULLs ev->ev.ev_dict before TAILQ_REMOVE; udev_event_externalize no longer releases it.
  • make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ rc=0, 0 errors (full output in fix_build.log, 35573 lines). Artifacts: kernel.stripped (15705800 B), kernel.debug (119340832 B).
  • Installed bare /boot/kernel/kernel (loader boots the bare name) + kernel.debug, sha β†’ ac173e08...ed6f. Rebooted. kern.version now reads #1: Thu Jul 2 15:06:40 UTC 2026 (the build timestamp, NOT #0).

After (patched #1 single-fix kernel)

  • Ran the same ./udev_uaf trigger 3Γ— back-to-back as root:
  • 3/3 runs completed all 80 create/destroy cycles (160 events each, 480 events total), printed no panic after 80 create/destroy cycles, exited 0. Guest stayed UP after every run.
  • grep -ciE "panic:|fatal trap|already free|ocnt != 0|Memory modified" over the entire boot.log after all 3 runs = 0.
  • dmesg | grep -iE "slab|objcache|corrupt|modified|overflow" = empty (no silent heap corruption on this INVARIANTS-bearing kernel).
  • The only new boot.log/dmesg lines are benign tap<N>: MAC address messages from the create/destroy device cycles.

Why this is conclusive

The bug is deterministic, not a tight race (udev_lk serializes the two reads; reader 2's marker provably still precedes the event when reader 1 reads it). On #0 it fires on the first reader-2 event and panics instantly. The patched kernel survives 480 such events with the proplib _PROP_ASSERT(ocnt != 0) and malloc chunk_mark_free double-free detectors both active (the same diagnostics that caught the baseline). There is no masking β€” the fix closes the path.

fix_status = fixed. recommended_fix matches the finding markdown's proposal; fix.diff is the authoritative git-apply-able form.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. The exact ./udev_uaf trigger that panicked the unpatched #0 baseline on the first reader-2 event (ocnt != 0 in prop_object_release, guest DOWN) does NOT panic the single-fix #1 kernel: 3/3 runs completed 480 total udev events, exited 0, guest stayed UP, and boot.log + dmesg show zero panics/warnings (the proplib assertion and malloc double-free detectors that caught the baseline are still active). The fix correctly serializes the single release of the shared event dict into udev_clean_events_locked and removes the premature release from udev_event_externalize, closing the UAF/double-free.

baseline #0: panic: assertion "ocnt != 0" failed in prop_object_release ... udev_dev_read+0x162 (guest DOWN). patched #1: [!] no panic after 80 create/destroy cycles (160 udev events). RUN_EXIT=0 (3/3 runs, guest UP); boot.log panic grep = 0; dmesg warnings grep = empty. build: === NK_DONE rc=0 === (nativekernel, 0 errors).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 15:06:40 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (sha256 ac173e083fb91582c3a82c53de61a5a9a898582ed58c2c1701e34cc77403ed6f)

Confirmed kernel references

Detail

Exploit chain

root-local kernel DoS via deterministic double-free/UAF of the shared udev event dictionary (the second reader's udev_event_externalize passes the freed dict to prop_object_retain/release). Root prerequisite (/dev/udev is 0600 root:wheel) bounds the realistic impact ceiling at DoS, not LPE; the finding's Medium / PR:H classification is correct. No privilege-escalation chain developed (root already bounds it).

Evidence (decisive lines)

BASELINE #0 (run.baseline.log/panic.txt): panic: assertion "ocnt != 0" failed in prop_object_release at /usr/src/sys/libprop/prop_object.c:1085 / prop_object_release+0x277 / prop_object_release+0x277 / udev_dev_read+0x162 / dev_dread / devfs_fo_read / kern_preadv -- guest DOWN. PATCHED #1 (fix_run.log): 3/3 runs of ./udev_uaf printed 'no panic after 80 create/destroy cycles (160 udev events)', RUN_EXIT=0, guest UP; grep panic|fatal trap|already free|ocnt!=0|Memory modified over boot.log = 0; dmesg slab|objcache|corrupt grep = empty.

PoC changes

fix.diff unchanged from the prior session (matches the finding markdown's proposal; re-verified git apply --check passes and both hunks apply cleanly to /usr/src). Added Phase-8 fix-validation evidence to findings/poc/DF-0055/: fix_build.log (full nativekernel output, rc=0), fix_run.log (3/3 patched runs no-panic + boot.log/dmesg checks), run.baseline.log (Phase-8 baseline re-reproduction on #0), dmesg.txt (clean dmesg excerpt). Updated VERDICT.md with the fix-validation section and manifest.json with the new artifacts + fix_status/fixed.

Verified recommended fix

In sys/kern/kern_udev.c, remove prop_object_release(ev->ev.ev_dict) from udev_event_externalize (:571) and instead add 'prop_object_release(ev->ev.ev_dict); ev->ev.ev_dict = NULL;' in udev_clean_events_locked before the existing TAILQ_REMOVE. This makes each reader's externalize net-zero on the shared dict (retain on prop_dictionary_set, release when the temp dict is dropped) and frees the dict exactly once when the last reader's marker advances past the event and the event is reaped. Matches the finding proposal; the standalone git-apply-able diff is at findings/poc/DF-0055/fix.diff.

Verdict

REPRODUCED + FIX VALIDATED. The use-after-free of the shared udev event dictionary is real and deterministic: udev_event_externalize (sys/kern/kern_udev.c:548) calls prop_object_release(ev->ev.ev_dict) at :571 on the FIRST reader to externalize an event, freeing the shared proplib dict while the event remains on udev_evq (the field is never NULLed, so udev_clean_events_locked :535/:540 cannot reap it). The next reader's udev_dev_read (:811/:842) finds the same dangling non-NULL ev_dict and re-externalizes it, hitting prop_object_retain(freed) then a double-free/refcount-underflow. Confirmed on the unpatched #0 kernel: the PoC panicked deterministically with 'panic: assertion "ocnt != 0" failed in prop_object_release at prop_object.c:1085' entered via udev_dev_read (same signature as the prior 3/3 baseline runs). The fix moves the single release into udev_clean_events_locked (release + NULL before TAILQ_REMOVE) and removes the premature release from externalize, so each reader's externalize is net-zero on ev_dict and the dict is freed exactly once when the last reader's marker advances past the event. I built the single-fix kernel (#1), installed it, and re-ran the SAME PoC 3x: all 3 completed 80 create/destroy cycles (480 udev events total), exited 0, guest stayed UP, with ZERO panics in boot.log and ZERO slab/objcache warnings in dmesg (the proplib _PROP_ASSERT and malloc double-free detectors that caught the baseline are still active).