evdev driver teardown frees evdev_dev under open clients and in-flight writes: use-after-free with cross-object event injection and kernel wedge
Summary
evdev_unregister/evdev_free revoke clients and kfree(struct evdev_dev) without draining open fds or in-flight writes; dev_ops_remove_minor is async (no destroy_dev draining), so dtor locks freed memory, a multi-MB write keeps injecting through the dangling pointer (evdev=si_drv1 cached at entry, ec_revoked never re-checked), and open races the free across a sleeping kmalloc. VERIFIED on guest with -device usb-kbd: detach mid-write then re-attach -> events written to the deleted fd appeared on the NEW device's reader (cross-object injection, tagged values), 64MB write returned success through the dangling pointer, 6-cycle churn hard-wedged the guest (vCPU livelock). Fix validated on rebuilt kernel: zero cross-injection over 4 rounds + churn, writes abort cleanly, keystroke delivery unregressed. Requires root-held fd + device removal (physical unplug while Xorg holds node, or kldunload) - corruption/DoS, not unpriv escalation. Refcount + master-lock + per-event revoke-check fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4454 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| evdev_uaf.c | β | 4.9 KB | view raw | |
| build.sh | β | 114 B | view raw | |
| run.sh | β | 169 B | view raw | |
| build.log | β | 290 B | view raw | |
| run.log | β | 3.1 KB | view raw | |
| serial-full.log | β | 15.0 KB | view raw | |
| wedge_serial_tail.txt | β | 2.9 KB | view raw | |
| fix_iter1_panic.txt | β | 1.4 KB | view raw | |
| fix.diff | β | 6.1 KB | view raw | |
| fix_run.log | β | 1.8 KB | view raw | |
| env.txt | β | 348 B | view raw | |
| VERDICT.md | β | 5.8 KB | β raw | |
| verdict.json | β | 5.8 KB | view raw |
DF-4454 VERDICT β evdev client/evdev lifetime UAF across driver teardown
Status: reproduced. Impact: kernel memory corruption via dangling `struct evdev_dev *' (demonstrated cross-object write) + system wedge (DoS). Confidence: certain.
Root cause (line-accurate)
evdev_unregister() (sys/dev/misc/evdev/evdev.c:366-406) revokes all
clients and destroys the cdev, but nothing waits for the open file
descriptors (each holding a struct evdev_client' withclient->ec_evdev' pointing back at the evdev) to go away.
evdev_free() (evdev.c:113-121) then immediately `kfree(evdev, M_EVDEV)'.
Three use-after-free arms:
- evdev_dtor (cdev.c:160-173): every later close(2) of a stale fd executes `EVDEV_LOCK(client->ec_evdev)' β lockmgr on freed memory.
- evdev_write (cdev.c:233-265):
evdev' is cached at syscall entry and the event loop never re-checksclient->ec_revoked', so an in-flight multi-megabyte write keeps calling `evdev_inject_event()' on the freed struct for the whole buffer (uiomove sleeps make the window seconds wide). - evdev_open (cdev.c:98-158):
evdev = dev->si_drv1' is read before a sleepingkmalloc(M_WAITOK)'; the re-check under EVDEV_LOCK operates on an evdev that may already be freed (the comment "Avoid race with evdev_unregister" is insufficient β the lock itself is in freed memory).
Contrast with FreeBSD, whose evdev_unregister' usesdestroy_dev()', which
drains open references before the caller proceeds to free. The DragonFly
port replaced that with `dev_ops_remove_minor()' (cdev.c:733-742 β
kern_device.c:649 β devfs async message), which does NOT wait.
Reproduction on the audit guest
Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026
(X86_64_GENERIC), QEMU/KVM. ukbd (compiled in, EVDEV_SUPPORT) attached via
-device piix3-usb-uhci -device usb-kbd.
Sequence: open /dev/input/event4 β start 8-64 MB write of
{EV_KEY,KEY_Q,v}/{EV_SYN,SYN_REPORT,0x1337} pairs β QEMU monitor
device_del kbd1' (ukbd_detach β evdev_free, write still in flight) βdevice_add usb-kbd' (new evdev_dev re-uses the freed block) β reader on
the re-created event4.
Observed (see run.log):
- 3 of 3 well-timed rounds delivered magic events to the NEW device's client ring (19, 5, 2 events with value=0x1337) β cross-object event injection through the dangling pointer. Nobody wrote 0x1337 to the new fd.
- Full 64 MB write(2) returned success although the device was deleted mid-call (~2.79M events injected through freed/reused memory).
- One in-flight write failed with EINVAL once the reallocated evdev's flag bitmap changed under the stale loop β direct evidence the loop was reading foreign memory.
- 6-cycle device_del/device_add churn with a 256 MB write in flight: hard system wedge β ssh unresponsive, serial console silent (wedge_serial_tail.txt), QEMU at 87-394 %CPU (vCPU livelock on corrupted lockmgr state). The guest recovered only after several minutes.
- Closing stale fds (dtor arm) exercised lockmgr-on-freed-memory; on the baseline kernel this stayed silent (freed block retains stale lock values), it is the same UAF write primitive.
Why impact is not uid=0
Opening /dev/input/eventN requires passing `caps_priv_check_self(SYSCAP_RESTRICTEDROOT)' (cdev.c:110) β i.e. root β and the teardown trigger is a privileged unload or physical USB unplug. So this is a root/physical-triggered kernel UAF, not an unprivileged escalation. The demonstrated primitive is a stale-fd write into re-allocated kernel memory (attacker-controlled {type,code,value} triples against whichever object re-uses the block; M_EVDEV size class is shared by all evdev providers and client rings), plus lockmgr corruption of freed slab memory on close. Realistic ceiling on default configs: kernel memory corruption / hard DoS from device removal while a root compositor holds the node.
Fix validation
fix.diff (authored after reproduction; applied to a guest /usr/src copy,
kernel rebuilt with `make nativekernel KERNCONF=X86_64_GENERIC', installed,
rebooted β kernel md5 54c1a462e7f1c6bc303929ac0cd99ac3):
- refcount
ev_refs' on evdev_dev: 1 alloc ref (dropped in evdev_free), 1 cdev ref (dropped under the newevdev_master_lock' when si_drv1 is NULLed in evdev_unregister), 1 ref per open fd (dropped in evdev_dtor); kfree deferred to the last unref. - `evdev_master_lock' (master β state lock order) closes the evdev_open TOCTOU: si_drv1 is published/cleared and ref'd under it.
- evdev_write re-checks `client->ec_revoked' every event.
- evdev_dtor drops its reference before
kfree(client)'. Two earlier fix iterations of mine were themselves buggy (the compiler sinks theclient->ec_evdev' load past kfree β UB β causing NULL unref panics); documented in fix_iter1_panic.txt. Lesson recorded for the maintainer: keep the unref strictly before the client free.
Patched-kernel results (fixrun3/4/5/fixstress):
- in-flight writes abort at the first revoke (ENODEV, or EBADF from the devfs layer on the detached cdev) β no injection through freed memory;
- zero magic events on the re-created device across 4 rounds + the identical 6-cycle churn that wedged the baseline β guest stayed up;
- stale fds close with no panic; the dtor runs against reference-pinned memory;
- functional smoke test: QEMU `sendkey a/b' events flow to a reader on event4 unchanged (correct 24-byte input_event records).
fix_status: fixed (baseline_reproduced=1, patched_reproduced=0).
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
fixedValidated on a rebuilt+rebooted guest kernel: the exact PoC that delivered 2-19 magic events per round on baseline delivered ZERO across 4 rounds on the patched kernel; in-flight writes abort with ENODEV (revoked re-check) or EBADF (devfs detached cdev) instead of running to completion through freed memory; the identical 6-cycle detach/attach churn that hard-wedged the baseline left the patched guest fully responsive; stale-fd closes are clean; keystroke delivery is unregressed (sendkey smoke test). Two earlier iterations of the fix itself panicked in evdev_dtor (compiler-sunk load past kfree) and are documented in fix_iter1_panic.txt.
fix_run.log (patched-kernel runs fixrun3/4/5/fixstress: UAF-NOT-CONFIRMED, magic_hits=0, guest up; smoke-test hexdump of KEY_A down/up); run.log + wedge_serial_tail.txt (baseline: UAF-CONFIRMED, wedge); fix.diff (v3); fix_iter1_panic.txt
Confirmed kernel references
- sys/dev/misc/evdev/evdev.c:113
- sys/dev/misc/evdev/evdev.c:366
- sys/dev/misc/evdev/cdev.c:110
- sys/dev/misc/evdev/cdev.c:160
- sys/dev/misc/evdev/cdev.c:233
- sys/dev/misc/evdev/cdev.c:98
- sys/bus/u4b/input/ukbd.c:1418
- sys/bus/u4b/input/ums.c:776
- sys/dev/misc/kbdmux/kbdmux.c:464
- sys/dev/misc/kbd/atkbd.c:565
- sys/dev/misc/psm/psm.c:2112
Detail
Exploit chain
root fd on /dev/input/event4 (or physical unplug while Xorg holds it) -> ukbd_detach -> evdev_unregister (clients revoked, not drained) -> evdev_free kfree's evdev_dev -> (a) in-flight write(2) continues evdev_inject_event() on freed memory for the whole buffer; (b) re-attach re-allocates the same M_EVDEV block -> stale writer injects attacker-chosen {type,code,value} events into the new device (demonstrated with value=0x1337) and races lockinit on the block -> lockmgr state corruption -> kernel livelock/wedge (demonstrated); (c) close(2) of stale fd -> evdev_dtor EVDEV_LOCK on freed memory. No unprivileged entry path on default config.
Evidence (decisive lines)
run.log (assembled guest outputs: 'UAF-CONFIRMED ... magic_hits=19/5/2', writer_bytes=67108848 writer_err=0 through a deleted device); wedge_serial_tail.txt + serial-full.log (baseline 6-cycle churn wedge); fix_run.log + fix_iter1_panic.txt (patched-kernel validation incl. two documented buggy fix iterations); evdev_uaf.c/build.sh/run.sh (reproduce); fix.diff (validated fix); env.txt
PoC changes
Fixed the seed PoC concept into a runnable program: rounded the write buffer down to whole input_events (24 B) or the kernel rejects with EINVAL at cdev.c:252; added an event stream that survives sparse filtering ([KEY_Q down/up][SYN_REPORT MAGIC] pairs); final version opens the reader with O_NONBLOCK (baseline runs used a blocking reader since events were flowing); added writer errno/byte accounting and magic detection. Host-side orchestration (device_del/device_add racing the write) driven via the QEMU monitor socket since the guest cannot hot-remove its own USB device.
Verified recommended fix
Reference-count evdev_dev (alloc/cdev/per-open refs, final kfree deferred to last unref), serialize si_drv1 publish/teardown behind a global evdev_master_lock so evdev_open cannot lock freed memory, and re-check client->ec_revoked every event in the evdev_write loop (fix.diff)
Verdict
Reproduced on the QEMU guest (DragonFly 6.5-DEVELOPMENT X86_64_GENERIC, ukbd on UHCI). Driver teardown (ukbd_detach -> evdev_free) frees struct evdev_dev while fds are open and a write(2) is in flight; the in-flight write kept injecting through the dangling pointer (full 64 MB write returned success), and after the freed block was re-allocated by a re-attached keyboard, magic-tagged events written to the DELETED device's fd appeared on the NEW device's reader (19/5/2 hits across rounds) - a demonstrated cross-object kernel write. The 6-cycle detach/attach churn hard-wedged the guest (ssh dead, serial silent, vCPUs spinning at 394% CPU). Trigger requires a root-held fd (evdev_open enforces SYSCAP_RESTRICTEDROOT; nodes are root:input) plus device removal (physical unplug / kldunload), so this is a root/physical-triggered kernel UAF with corruption+DoS impact, not an unpriv->root chain. Fix.diff v3 (ev_refs refcounting + evdev_master_lock serialization of si_drv1 publication/teardown + per-event revoked re-check in evdev_write + unref-before-kfree in the dtor) validated on a rebuilt kernel: writes abort with ENODEV/EBADF at revoke, zero cross-injection across 4 rounds and the identical churn stress, clean stale-fd closes, keystroke delivery unregressed.
No comments yet.