โฌข DragonFlyBSD Kernel Audit
โ† 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.

PoC verification

Evidence pack

findings/poc/DF-0055 ยท 12 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 build output 718 B view raw
run.log run-log RUN 1 (decisive): double-free panic 'memory chunk is already free' 1.5 KB view raw
run.2.log run-log RUN 2 (confirmation): refcount-underflow panic 'ocnt != 0' 1.4 KB view raw
run.3.log run-log RUN 3 (confirmation): identical to RUN 2 1.3 KB view raw
panic.txt panic-signature all three 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 8.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
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
โ†“ 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.

Confirmed kernel references

Detail

Exploit chain

Memory-corruption class (kernel UAF + double-free on a prop_dictionary object in a general kmalloc bucket). On a non-INVARIANTS kernel the freed object could in principle be reclaimed with a victim carrying attacker-controlled bytes, and the subsequent prop_object_retain/release/pot_free indirect calls through po->po_type would be hijackable function-pointer targets. However the trigger already requires root (root->kernel code exec is not a meaningful privilege boundary on DragonFly), AND the demonstrated panics fire on the very first event the second reader touches -- before any reclamation window -- so without first defeating the allocator's double-free detection the bug expresses as a reliable panic, not a controlled write. Realistic ceiling: reliable local kernel DoS from root, matching the filing. No full LPE chain developed (disproportionate to the root prerequisite).

Evidence (decisive lines)

findings/poc/DF-0055/ holds the full pack: udev_uaf.c (rewritten deterministic trigger), build.sh/run.sh, build.log, run.log/run.2.log/run.3.log (3 decisive runs), panic.txt (all three serial-console panic dumps from dfbsd-qemu/boot.log), env.txt, VERDICT.md (full mechanism walkthrough with path:line), manifest.json, and fix.diff. Decisive bytes: 'panic: memory chunk 0xfffff80067b36860 is already free!' via chunk_mark_free<-_kfree<-_prop_dictionary_free<-prop_object_release<-udev_dev_read (RUN1), and 'panic: assertion "ocnt != 0" failed in prop_object_release at prop_object.c:1085' via prop_object_release<-prop_object_release<-udev_dev_read (RUNs 2,3).

PoC changes

Rewrote the seeded udev_uaf.c sketch (which opened two fds in one process, read in a busy loop with usleep, and never generated any device events -- so it would block forever on an empty queue) into a deterministic trigger: fork() two reader children each opening /dev/udev (own softc/marker) and blocking in read() to auto-insert both markers at TAILQ_HEAD, then the parent generates a stream of udev events via /sbin/ifconfig tap create/destroy (make_dev/destroy_dev on tap_ops -> udev_event_attach/detach -> udev_event_insert). Fixed one compile error (stray '/ temp /' inside the header block comment prematurely closed the outer comment). Added build.sh, run.sh, VERDICT.md, manifest.json, fix.diff, env.txt, panic.txt, and run.{,2.,3.}log.

Verified recommended fix

fix.diff (git apply --check verified): in udev_clean_events_locked (kern_udev.c:535) add prop_object_release(ev->ev.ev_dict) + ev->ev.ev_dict=NULL before TAILQ_REMOVE so the shared dict is released exactly once when the event is reaped (after all readers pass it), and remove the prop_object_release(ev->ev.ev_dict) at udev_event_externalize:571 so each reader merely borrows the dict (net-zero retain via the temp dict set/release). Matches the finding markdown's proposal.

Verdict

REPRODUCED. The cited path in sys/kern/kern_udev.c is exactly as claimed: udev_event_externalize (kern_udev.c: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 with a dangling, never-NULLed field. The per-softc marker system guarantees the event is still queued for the next reader (a reader cannot advance another reader's marker, and udev_clean_events_locked at :535/:540 stops at the trailing reader's NULL-dict marker), so reader 2's read (:842) re-derives the dangling pointer and at :566 passes it to prop_dictionary_set -> prop_object_retain(freed) (prop_object.c:992) -> UAF write; the subsequent :571 release then drives a double-free/refcount-underflow. 3/3 fresh-vm.sh-reset runs panicked deterministically in udev_dev_read -> udev_event_externalize -> prop_object_release, with two distinct but same-root-cause signatures (malloc 'memory chunk is already free' and proplib '_PROP_ASSERT(ocnt != 0)' at prop_object.c:1085). The trigger is deterministic, not a tight race. /dev/udev is 0600 root:wheel (kern_udev.c:1039-1041), so this is a root-local DoS exactly as the Medium/CVSS PR:H finding states.