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
/* 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/udev0600). - 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/udevaccess (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).
Recommended fix
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
sys/kern/kern_udev.c:566,571โ the release-without-NULL in externalize.sys/kern/kern_udev.c:539-540โ theev_dict != NULLclean predicate.- CWE-416 Use After Free.
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| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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, predicateev_dict != NULLat:540) leaves the event in the queue (it is stopped at the trailing reader's marker, which has a NULLev_dict).- The next reader's marker-walk (
:838-840) finds the same event (dangling non-NULLev_dictpasses the skip loop), callsudev_event_externalize()again, which at:566passes the freed pointer toprop_dictionary_set()โprop_object_retain(freed)โ UAF write; the subsequent:571release 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.
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.
-
Event enqueue โ
udev_event_insert()sys/kern/kern_udev.c:501prop_dictionary_copy()s the dict (refcount = 1) and stores it inev->ev.ev_dict(:512), thenTAILQ_INSERT_TAIL(&udev_evq, ev, link)(:516). -
Reader read path โ
udev_dev_read():811finds the next event after the reader's marker, skippingNULL-dict nodes (:838-840), and callsudev_event_externalize(ev)(:842). -
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.
-
Why the event is not reaped between readers โ
udev_clean_events_locked():535reaps fromTAILQ_FIRSTwhileev->ev.ev_dict != NULL(:540). The loop stops at the firstNULL-dict node. The trailing reader's marker (astruct udev_event_kernelwhoseev_dictisNULL, 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. -
The UAF โ reader 2's
udev_dev_read()finds the same event (dangling non-NULLev_dictpasses the:839skip loop and the:540predicate), callsudev_event_externalize()again, which at:566passes the freed pointer toprop_dictionary_set()->prop_object_retain(freed)(prop_object.c:992,atomic_inc_32_nv(&po->po_refcnt)โ a write to freed memory). The subsequentprop_object_release()at:571then 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/udevis0600 root:wheel(kern_udev.c:1039-1041);maxx(uid 1001, not in wheel) getsPermission deniedand 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:
- The trigger already requires root. A root attacker can already
kldloadarbitrary 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. - 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_releasere-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, eachopen("/dev/udev")(own softc/marker) and block inread()โ auto-initiating both markers atTAILQ_HEADbefore any event exists.- Parent waits for both to be ready, then generates a stream of udev
events via
ifconfig tap<N> create/destroy(eachmake_dev/destroy_devontap_opscallsudev_event_attach/_detachโudev_event_insert; confirmed atsys/net/tap/if_tap.c:289,491andsys/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.
Recommended fix
Authored in fix.diff (standalone, git apply-able, verified with
git apply --check). Matches the finding markdown's proposal:
- In
udev_clean_events_locked()(:535), addprop_object_release(ev->ev.ev_dict); ev->ev.ev_dict = NULL;before the existingTAILQ_REMOVE/objcache_putโ so the shared dict is released exactly once, when the event is reaped (after all readers have advanced past it). - Remove the
prop_object_release(ev->ev.ev_dict)atudev_event_externalize():571, so each reader merely borrows the dict (the tempdict's set/release is net-zero onev_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
- sys/kern/kern_udev.c:512
- sys/kern/kern_udev.c:535
- sys/kern/kern_udev.c:540
- sys/kern/kern_udev.c:548
- sys/kern/kern_udev.c:566
- sys/kern/kern_udev.c:571
- sys/kern/kern_udev.c:811
- sys/kern/kern_udev.c:842
- sys/libprop/prop_object.c:987
- sys/libprop/prop_object.c:1085
- sys/libprop/prop_dictionary.c:995
- sys/vfs/devfs/devfs_core.c:1428
- sys/vfs/devfs/devfs_core.c:1451
- sys/net/tap/if_tap.c:289
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
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.